Skip to content

feat: meter coding-agent spend and cap it per hour - #184

Merged
jeremymcs merged 1 commit into
mainfrom
jeremymcs/next-feat-set
Aug 30, 2026
Merged

feat: meter coding-agent spend and cap it per hour#184
jeremymcs merged 1 commit into
mainfrom
jeremymcs/next-feat-set

Conversation

@jeremymcs

Copy link
Copy Markdown
Owner

Closes the follow-up tracked in docs/plans/resilient-automation.md:71-73.

Why

PatchDeck could not say how much paid agent work it had done. agent_runs looks like a spend ledger but is a per-babysit-session record — one row per session (babysitter.ts:3256-3282), written only by babysitter.ts, with pr_id NOT NULL ... ON DELETE CASCADE. So issue work, CI healing, deployment healing, release notes, social posts, and PR Q&A were invisible, and PR-scoped history was destroyed with the PR.

That made "should I leave Auto PRs and Auto Issues on overnight?" an open-ended question. This makes it a bounded one.

What

Ledger. New agent_invocations table: one row per codex/claude process spawn, with duration, exit code, resolved agent, model, and outcome. Deliberately no foreign keys, so spend history survives deletion of the PR or issue that caused it. agent_runs is untouched.

Attribution. Every paid path already funnelled through runAgentCommand (agentRunner.ts:132), so the meter hooks in there. Each unit of work establishes an AsyncLocalStorage context via withAgentWork(...); nested contexts win, so CI healing bills as heal_ci rather than babysit_pr. A guard test pins the set of modules that reach an agent primitive, so a new call site cannot silently escape the meter.

Ceiling. maxAgentInvocationsPerHour, default 0 = unlimited — upgrading changes nothing. Two gates, because one is not enough:

  1. Dispatcher (resolveClaimableKinds) stops claiming AGENT_INVOKING_JOB_KINDS when the ceiling is reached. Jobs stay queued, exactly as under drain mode.
  2. Invocation refuses the spawn — this catches CI healing (started inside a leased babysit_pr job), the fallback-agent re-run at babysitter.ts:4211, and conflict repair, which the dispatcher cannot see.

The refusal classifies as transient in failureRecovery.ts, so the job backs off on the free cap instead of burning a paid maxAgentRetryAttempts slot — the agent never ran.

The window rolls continuously; resetsAt is when the oldest invocation ages out, not the top of the hour. Health-check probes are recorded but never counted, so opening Settings cannot exhaust a budget. Rows prune after 30 days on the existing retention sweep, and rows left running by a hard shutdown are closed at boot.

Surfaces. GET /api/agent-spend, the get_agent_spend MCP tool, a header pill (only when a ceiling is set; amber at 80%, "paused until HH:MM" at the ceiling), and a Settings field beneath Max agent retry attempts.

Tests

New coverage for each behaviour change:

  • agentSpend.test.ts — context nesting and unwind, ledger rows per spawn with duration/outcome, non-zero/timeout/thrown classification, refusal at the ceiling, rolling-window clearing, probes excluded, unattributed spawns still counted, and the call-site attribution guard.
  • storage.test.ts — round-trip on both storages, window boundary, probe exclusion, orphan close, and a row surviving removePR.
  • backgroundJobDispatcher.test.ts — at the ceiling, agent kinds go unclaimed while sync_watched_repos still flows, the job stays queued with attemptCount: 0, and raising the ceiling releases it.
  • failureRecovery.test.ts — the refusal is transient, and survives losing its class across a serialization boundary.
  • routes.test.ts, defaultConfig.test.ts, logsRetention.test.ts.

Verification

npm run check, npx eslint ., npm run build, npm run test:all (827 tests) — all green.

Plan and decision record: docs/plans/agent-spend-metering.md.

PatchDeck could not say how much paid agent work it had done. `agent_runs` is a
per-babysit-session record with a `pr_id` foreign key, so issue work, CI and
deployment healing, release notes, social posts, and PR questions were invisible,
and PR-scoped history was cascade-deleted with the PR.

Adds a separate `agent_invocations` ledger — one row per `codex`/`claude` process
spawn, with duration, exit code, resolved agent, model, and outcome, and no
foreign keys so history outlives its target. Every spawn already funnelled
through `runAgentCommand`, so metering hooks in there; an AsyncLocalStorage
context established by each unit of work attributes the row.

Adds `maxAgentInvocationsPerHour` (default 0, unlimited). When the rolling-hour
ceiling is reached the dispatcher stops claiming agent-invoking job kinds, and
any spawn started inside an already-running job — CI healing, agent fallback,
conflict repair — is refused. Queued work is not failed: it waits and resumes as
the window rolls. The refusal classifies as transient so it never consumes a paid
retry attempt.

Health-check probes are recorded but never counted, so opening Settings cannot
exhaust a budget. Ledger rows are pruned after 30 days on the existing retention
sweep, and rows left `running` by a hard shutdown are closed at boot.

Surfaced through `GET /api/agent-spend`, the `get_agent_spend` MCP tool, a header
pill shown once a ceiling is set, and a Settings field.

Plan: docs/plans/agent-spend-metering.md

Verified with: npm run check, npx eslint ., npm run build, npm run test:all (827)
@github-actions

Copy link
Copy Markdown
Contributor

✅ Documentation Updated

This PR includes documentation changes:

docs/public/configuration.md

The docs build succeeded. Changes will be deployed to GitHub Pages when merged to main.

@jeremymcs
jeremymcs merged commit 55f0a00 into main Aug 30, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant